Skip to content

RFC: Interoperable exceptions #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

cometkim
Copy link
Member

@cometkim cometkim commented Apr 29, 2025

@cometkim cometkim force-pushed the interoperable-exceptions branch 2 times, most recently from bd7c3ce to 8656647 Compare May 14, 2025 20:28
@cometkim cometkim force-pushed the interoperable-exceptions branch from 8656647 to 56cccc9 Compare May 14, 2025 20:29
@cometkim cometkim marked this pull request as ready for review May 14, 2025 20:36
@cristianoc
Copy link

Reporting here some comments on the preparation work to happen before this RFC.
This RFC would be part 3 below.
If this division sounds sensible, perhaps we could create parent issues/RFCs for parts 1 and 2.

In terms of this RFC, one question is: how does one construct custom exceptions? The RFC describes how to pattern match them, but not how to construct and raise them. Here I'm assuming that there's a need to construct custom exceptions. If not, curious to learn why.


Back to the runtime representation of exceptions, I think it's useful to break it up into parts, so that the issues addressed and the design space can be a bit clearer.

  1. representation of ReScript exceptions, is currently an object. This is not standard.
  2. pattern matching wraps JS exceptions into ReScript ones. This has surprising effects, such as try foo() { | e => raise(e)} does not do what you expect. It wraps a JS exception into a ReScript one (an object), and then throws that object.
  3. JS exceptions can take many forms (one can throw e.g. booleans as well as errors) and currently there are no facilities to handle those nicely on the ReScript side.

For 1, a standard-looking way would be to declare a ReScriptError subclass of Error, and use its constructor to set the name and payload (A and 42 for ReScript exception value A(42)).

For 2, pattern matching would need to change so that JS exceptions are left untouched, not wrapped. It seems the simplest way would be to handle ReScript exception matching | A(n) => ... as simply checking instanceof ReScriptError (then extract name and payload). So no runtime would be used at all in pattern matching.

For 3, there are many things one could do, and depend anyway on doing 1 and 2 first.

@cometkim
Copy link
Member Author

cometkim commented May 15, 2025

There is already an open PR (rescript-lang/rescript#6979) and discussions for 1 and 2, and I thought we had already shared the same goals, but implicitly.

  • Use Error subclass as runtime representation.
  • Put payloads into Error.prototype.cause
  • Don't wrap JavaScript exceptions when rethrow.

If we want to make the goals more clearer, I can write up another RFC for it with @DZakh

However, the decision to make the payload count limit will also be an important consideration there as well. To use Error.prototype.cause effectively. rescript-lang/rescript#6979 (comment)

@cometkim
Copy link
Member Author

cometkim commented May 15, 2025

In terms of this RFC, one question is: how does one construct custom exceptions? The RFC describes how to pattern match them, but not how to construct and raise them. Here I'm assuming that there's a need to construct custom exceptions. If not, curious to learn why.

If a custom @check attribute is given, the compiler will use it as-is. The constructors should behave like unboxed variants.

I will update the content to make it clearer.

@DZakh
Copy link
Member

DZakh commented May 15, 2025

The problem with payload in a cause is that it might not be displayed in a terminal in some environments

@DZakh
Copy link
Member

DZakh commented May 15, 2025

So it'll be a regression in terms of debugging.

@cometkim
Copy link
Member Author

That's true if users are still on Node.js version 14 or Chrome 8x. Or custom renderers like React Native or vConsole. We need to investigate further, but I think they should be fine since they use the native toString().

@DZakh
Copy link
Member

DZakh commented May 15, 2025

When I was working on the task, my final thought was that it's better to use custom error classes. For example, this would turn into this:

exception MyError(JsError.t)
class MyError extends Error {
  constructor(_0) {
    super()
    this._0 = _0
  }
}

And:

exception MyError({
  message: string,
  err: JsError.t,
})

Console.log(MyError({message: "foo", err}))
class MyError extends Error {
  constructor(message, err) {
    super()
    this.message = message
    this.err = err
  }
}

console.log(new MyError("foo", err))

This way we'll be able to have:

  • Better interop with JS
  • Better display in logs
  • Ability to customise error message
  • Keep the runtime representation of the data flattened the same as before (eg exn._0 vs exn.cause._0)
  • We can even keep TAG for backward compatibility

@cometkim
Copy link
Member Author

cometkim commented May 15, 2025

This way we'll be able to have:

  • Better interop with JS
  • Better display in logs

It's actually the opposite. The cause payload is standard for reporting additional structured information on exceptions, so most engines and integrations support it. But for any other custom fields in an Error subclass, they have different behavior across engines and tools.

Try your examples in Firefox browser.

So the most widely supported form is

class ReScriptError extends Error {
  constructor(id, exn) {
    super();
    this.RES_EXN_ID = id;

    // behave like a normal field even if no `Error.prototype.cause` support
    this.cause = exn;
  }
}

So I expect the runtime representation of MyError({ message: "foo", err }) will be "inverted" form of the original.

new ReScriptError(RES_EXN_ID, {
  message: "foo",
  err,
});

I also suggested using the Symbol.for() in the old discussion. Because we're doing exactly the same thing to create a unique RES_EXN_ID symbol, which is natively supported in ES6, so there is no reason to keep an additional runtime registry.

However, I'm not particularly opposed to exporting exceptions as their own Error subclass, as long as it's not too complicated.

@cometkim
Copy link
Member Author

cometkim commented May 15, 2025

Providing a ReScriptError class helps JavaScript users easily classify errors thrown from the ReScript side. We could use either approach.

// in primitives
export class ReScriptError extends Error {
  constructor(id, exn) {
    super("ReScript exception: " + id);
    this.RES_EXN_ID = id;
    this.cause = exn;
  }
}

// in the user module
import { ReScriptError } from "@rescript/runtime/es6/Primitive_extensions.js";

export class MyError extends ReScriptError {
  constructor(exn) {
    super("MyModule.ReScriptError", exn);
  }
}

@cristianoc
Copy link

Providing a ReScriptError class helps JavaScript users easily classify errors thrown from the ReScript side. We could use either approach.

// in primitives

export class ReScriptError extends Error {

  constructor(id, exn) {

    super("ReScript exception: " + id);

    this.RES_EXN_ID = id;

    this.cause = exn;

  }

}



// in the user module

import { ReScriptError } from "@rescript/runtime/es6/Primitive_extensions.js";



export class MyError extends ReScriptError {

  constructor(exn) {

    super("MyModule.ReScriptError", exn);

  }

}

A single class is definitely more manageable than one class per exception declaration.

@cristianoc
Copy link

How important is the restriction to a single payload?
It would be nice to avoid breaking existing code if not strictly necessary.
Eg in case of multiple payloads, the runtime representation could put them into an array, or object. It's still possible to recommend using a single payload, rather than enforcing it, if there are downsides to this (which I'd like to learn about).

@DZakh
Copy link
Member

DZakh commented May 16, 2025

Having a single class like ReScriptError will make it always display it in the logs before a message. I'd prefer having MyError instead of ReScriptError.

@DZakh
Copy link
Member

DZakh commented May 16, 2025

Also, cause is not for payload, but for the original error, when you rethrow it. It's displayed nicely when cause is an instance of Error.

It might work in some environments, but this one I tested was like this one.

And I really don't want to change the internal representation of the exn payload.

@DZakh
Copy link
Member

DZakh commented May 16, 2025

So, I'd say the rfc can be split into two parts:

  • Turn exn to be an instance of error. Can continue using TAG for this.
  • Improve pattern matching on exceptions

@cometkim
Copy link
Member Author

cometkim commented May 16, 2025

Also, cause is not for payload, but for the original error, when you rethrow it.

That's not true. cause is for any value that can be thrown.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause#description

The value of cause can be of any type. You should not make assumptions that the error you caught has an Error as its cause, in the same way that you cannot be sure the variable bound in the catch statement is an Error either. The "Providing structured data as the error cause" example below shows a case where a non-error is deliberately provided as the cause.

It's displayed nicely when cause is an instance of Error.

Any payload in cause should be printed as well. The formatter in many console environments does not check its type and just formats it normally.

Better formatter compatibility is why the standard did not choose another Error subclass.

https://github.com/tc39/proposal-error-cause?tab=readme-ov-file#why-not-custom-subclasses-of-error

@cometkim
Copy link
Member Author

I guess you're mainly testing on Chrome.
See also: https://issues.chromium.org/issues/40182832

Chromium DevTools seems to be the only one with the issue, and it needs to be fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants